Skip to content

feat: local auth bootstrapping - #7671

Open
njhale wants to merge 1 commit into
obot-platform:mainfrom
njhale:local-auth-bootstrap
Open

feat: local auth bootstrapping#7671
njhale wants to merge 1 commit into
obot-platform:mainfrom
njhale:local-auth-bootstrap

Conversation

@njhale

@njhale njhale commented Aug 27, 2026

Copy link
Copy Markdown
Member

Provision the first local auth owner from deployment settings and let that owner
switch to a replacement auth provider without a bootstrap token.

An operator sets an owner email and a high-entropy setup token. Obot configures
local auth, creates the owner with an unusable password, and stores only a hash
of the setup token. The owner activates through a fragment-delivered link, and
their session can do nothing but read its own profile, set a password, or sign
out until setup is complete. A completed account is never rearmed or reset from
environment settings.

Switching away from local auth stages the replacement's settings while local
auth keeps serving logins, verifies them through a one-time login bound to the
verification that issued it, and then activates as a single provider
configuration change so a partial failure cannot lock everyone out.

Addresses #7565

@njhale

njhale commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This automated review found real issues on this local-auth bootstrapping PR — see the inline comments (an open-redirect on the change-password route, an ownership-check ordering issue in EnsureInitialOwner, and missing colocated specs for the new routes/component) — so a human review is still needed. Beyond those, I also checked the bootstrap-token disabling logic in pkg/services/config.go (OBOT_BOOTSTRAP_TOKEN is ignored whenever the initial-owner env vars are set) and confirmed it's an intentional mutual-exclusivity design between local-auth bootstrapping and legacy bootstrap-token auth, not a bug.

Extended reasoning...

Beyond the three confirmed inline findings, I examined pkg/services/config.go around line 1069-1117 where bootstrapOptions = append(bootstrapOptions, bootstrap.Disabled()) unconditionally disables OBOT_BOOTSTRAP_TOKEN auth whenever the initial local-auth owner env vars (LocalAuthInitialOwnerEmail/LocalAuthInitialOwnerSetupToken) are configured, regardless of whether the owner has already been provisioned. This looked at first glance like it could permanently lock out bootstrap-token access, but reading the surrounding code confirms it is a deliberate design choice: local-auth owner provisioning and the legacy bootstrap-token mechanism are treated as mutually exclusive auth-bootstrapping paths, with a slog.Warn emitted to inform the operator when the token is being ignored. This is consistent with the ADR's described intent and is not a defect. The other candidate I looked at (missing spec for routes/activate/+page.svelte:61) duplicates one of the three findings already queued as inline comments, so it isn't new information. Given the security-sensitive nature of this change (auth bootstrapping, password-change flows, redirect handling) and the confirmed open-redirect and ordering bugs already flagged inline, this PR clearly warrants human review regardless of this additional check.

Comment thread ui/user/src/routes/change-password/+page.svelte
Comment thread pkg/localauth/users.go Outdated
Comment thread ui/user/src/routes/activate/+page.svelte

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds secure Local-auth bootstrapping for provisioned Obot environments using expiring setup links and restricted password-setup sessions.

Changes:

  • Provisions an initial owner and disables bootstrap authentication.
  • Adds activation, forced password-change flows, and backend restrictions.
  • Updates admin UI, tests, Helm configuration, documentation, and ADR.

Reviewed changes

Copilot reviewed 44 out of 45 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
ui/user/src/routes/change-password/page.svelte.spec.ts Tests password-change UI.
ui/user/src/routes/change-password/+page.ts Guards password-change route.
ui/user/src/routes/change-password/+page.svelte Implements password setup form.
ui/user/src/routes/activate/page.svelte.spec.ts Tests account activation UI.
ui/user/src/routes/activate/+page.svelte Exchanges setup-link tokens.
ui/user/src/routes/+layout.ts Redirects restricted users.
ui/user/src/lib/services/user/types.ts Exposes password-change state.
ui/user/src/lib/services/user/operations.ts Adds activation/password APIs.
ui/user/src/lib/services/admin/types.ts Extends Local user metadata.
ui/user/src/lib/services/admin/operations.ts Sends password-change options.
ui/user/src/lib/constants.ts Allows anonymous activation route.
ui/user/src/lib/components/admin/LocalAuthConfigure.svelte.spec.ts Tests admin password controls.
ui/user/src/lib/components/admin/LocalAuthConfigure.svelte Adds forced-change management.
pkg/storage/openapi/generated/openapi_generated.go Updates generated user schema.
pkg/services/config.go Configures initial-owner provisioning.
pkg/proxy/proxy.go Propagates restricted-session state.
pkg/localauth/users.go Implements provisioning and password changes.
pkg/localauth/users_test.go Tests initial-owner lifecycle.
pkg/localauth/provider.go Adds activation sessions and redirects.
pkg/localauth/password_test.go Removes superseded redirect tests.
pkg/gateway/types/localauth.go Adds setup state fields.
pkg/gateway/server/user.go Returns password-change requirement.
pkg/gateway/client/localauth.go Persists activation and completion state.
pkg/gateway/client/localauth_test.go Tests setup-session behavior.
pkg/gateway/client/identity_user_limit_test.go Adapts Local user test API.
pkg/bootstrap/bootstrap.go Supports disabling bootstrap login.
pkg/bootstrap/bootstrap_test.go Tests disabled bootstrap state.
pkg/auth/redirect.go Centralizes safe redirects.
pkg/auth/redirect_test.go Tests redirect sanitization.
pkg/auth/auth.go Extends serialized auth state.
pkg/api/server/server.go Enforces restricted-session access.
pkg/api/server/password_change_test.go Tests allowed restricted requests.
pkg/api/router/router.go Registers activation endpoints.
pkg/api/handlers/localauth.go Handles activation and password completion.
pkg/api/authz/authz.go Authorizes setup-flow requests.
docs/docs/installation/enabling-authentication.md Documents initial-owner setup.
docs/docs/installation/docker-deployment.md Adds Docker setup guidance.
docs/docs/configuration/server-configuration.md Documents new environment variables.
docs/docs/configuration/auth-providers.md Documents forced password changes.
chart/values.yaml Adds Helm setup values.
chart/templates/secret.yaml Validates and stores setup secrets.
chart/templates/NOTES.txt Prints activation guidance.
chart/templates/internal-configmap.yaml Prevents secret ConfigMap exposure.
apiclient/types/user.go Extends API user representation.
adr/2026-08-14-initial-local-owner-setup.md Records the architectural decision.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/gateway/client/localauth.go
Comment thread pkg/api/handlers/localauth.go
Comment thread pkg/services/config.go
Comment thread ui/user/src/routes/activate/+page.svelte Outdated
Comment thread docs/docs/installation/enabling-authentication.md Outdated
@njhale
njhale force-pushed the local-auth-bootstrap branch from 6126c1e to 835ba87 Compare August 29, 2026 01:54
@njhale
njhale marked this pull request as ready for review August 31, 2026 13:44
Copilot AI review requested due to automatic review settings August 31, 2026 13:44

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@njhale
njhale requested a balanced review from Copilot August 31, 2026 13:45
@njhale

njhale commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 pkg/gateway/server/oauth.go — The new auth-provider Verify flow is broken end-to-end: redirect() unconditionally calls CreateAPIKeyFromSetupTokenRequest, which still rejects any TokenRequest.Purpose other than TokenRequestPurposeSetup (pkg/gateway/client/apikey.go:240), but Verify() creates its token request with the new TokenRequestPurposeAuthProviderVerify. Every admin who stages a replacement provider and completes the verification login gets an error instead of being promoted back to /admin/auth-providers, so staged providers can never be verified/activated via this feature. Fix: update CreateAPIKeyFromSetupTokenRequest (or add a verify-specific completion path) to accept TokenRequestPurposeAuthProviderVerify, matching the purposes already accepted by CreateTokenRequestState/VerifyTokenRequestState.

    Extended reasoning...

    Admin stages a replacement provider (Stage), calls Verify() which creates a TokenRequest{Purpose: TokenRequestPurposeAuthProviderVerify} and returns a redirectURL to /api/oauth/start/{id}/... . oauth.go's oauth() (line ~39) now accepts this purpose via the widened CreateTokenRequestState (token.go oauthRoundTripPurposes) and starts the OAuth dance. After a successful login with the staged provider, redirect() (oauth.go) calls VerifyTokenRequestState (also widened, succeeds) then unconditionally calls apiContext.GatewayClient.CreateAPIKeyFromSetupTokenRequest(ctx, userID, tr) at line 88. That function (pkg/gateway/client/apikey.go:240) still does if tr.Purpose != types.TokenRequestPurposeSetup { return error }, so it fails for the verify-purpose token, s.errorToken() records the error and returns HTTP 500 instead of redirecting to tr.CompletionRedirectURL (/admin/auth-providers?verified=...). Activate() can then never be reached because the caller never actually completes a successful sign-in through the staged provider via this path.

    Verification: normal severity — a new feature this PR adds is broken end-to-end. Verify() (pkg/api/handlers/authprovider.go:306-316) creates a TokenRequest with Purpose=TokenRequestPurposeAuthProviderVerify and redirects to /oauth/start/{id}/{ns}/{name}. The oauth start/redirect handlers (pkg/gateway/server/router.go:65-66 -> oauth.go) now accept this purpose because CreateTokenRequestState (token.go:181) and…

Comment thread pkg/api/handlers/authprovider.go
Comment thread pkg/proxy/proxy.go
Comment thread pkg/gateway/server/dispatcher/dispatcher.go Outdated
Comment thread pkg/api/handlers/authprovider.go
Copilot AI review requested due to automatic review settings August 31, 2026 18:07
@njhale

njhale commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Copilot AI review requested due to automatic review settings August 31, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@njhale
njhale force-pushed the local-auth-bootstrap branch from 538a7c5 to 424e65b Compare August 31, 2026 21:58
Copilot AI review requested due to automatic review settings August 31, 2026 21:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@njhale
njhale force-pushed the local-auth-bootstrap branch from 424e65b to eefd706 Compare September 1, 2026 12:00
Copilot AI review requested due to automatic review settings September 1, 2026 12:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical provider-verification flaw and multiple moderate security and reliability issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (4)

pkg/api/handlers/authprovider.go:275

  • The new staged settings are committed before the old verification cache is cleared. If this delete fails or the process stops here, the endpoint returns an error but the new settings remain staged; because activation matches proof only by provider name, the previous verification now authorizes activating settings that were never tested. Invalidate/version the proof atomically with replacing the staged credential (and account for in-flight verification callbacks).
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/api/handlers/localauth.go:174

  • This cookie-authenticated password mutation has no CSRF/origin check. Context.Read unmarshals JSON regardless of Content-Type, so a same-site sibling origin can submit a crafted text/plain form with the victim's Lax cookie, choose an attacker-known password, and complete the owner setup. Require a same-origin request or enforce an equivalent CSRF boundary before accepting the body.
	var body localAuthUserRequest
	if err := req.Read(&body); err != nil {
		return types.NewErrBadRequest("invalid request body: %v", err)
	}

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:258

  • The replacement credential is deleted before the outgoing provider is deconfigured and before status/daemon updates complete. If any later operation fails, reconciliation retries without the staged credential (or sees the incoming provider as configured), converts the retry into a terminal error, and leaves the switch partially applied. Keep the operation's retry inputs until all steps finish and make the switched reconciliation explicitly idempotent for partially promoted/deconfigured states.
		// Cleared here rather than in the caller, so there is no window where a provider is both
		// configured and still staged against itself.
		if _, err := h.gatewayClient.DeleteCredential(ctx, system.ReplacementAuthProviderCredentialContext, authProvider.Name); err != nil {
			return fmt.Errorf("clear staged configuration for auth provider %q: %w", authProvider.Name, err)
		}
		if err := h.deconfigureAuthProvider(ctx, client, outgoingProvider); err != nil {

ui/user/src/routes/admin/auth-providers/+page.svelte:557

  • This warning says everyone is signed out, but the verification session from the replacement provider is deliberately preserved so the user finishes the switch signed in. Describe the outgoing-provider sessions specifically to avoid misleading the owner about the post-switch state.
  • Files reviewed: 67/68 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread pkg/gateway/server/oauth.go
Comment thread ui/user/src/lib/components/admin/ProviderConfigure.svelte Outdated
Comment thread docs/docs/installation/enabling-authentication.md
Copilot AI review requested due to automatic review settings September 3, 2026 03:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical and moderate authentication-switching and owner-promotion issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/api/handlers/setup/confirm_owner.go:68

  • PromoteToOwner now creates a UserRoleChange itself, but ConfirmOwner still creates another one immediately below. A newly promoted bootstrap owner therefore emits two role-change resources and triggers duplicate propagation. Remove the old creation block from this handler.

pkg/api/handlers/authprovider.go:275

  • Clearing only the cached result after staging does not invalidate an already-issued verification token. An OAuth callback for the previous settings can race this call (or arrive after the same provider is re-staged), repopulate TempSetupUser, and then satisfy Activate for credentials it never verified. Bind the token request/cache entry to the staged credential version and reject stale callbacks, or invalidate outstanding verification requests as part of the serialized staging change.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

ui/user/src/routes/admin/auth-providers/+page.svelte:559

  • This warning is not universally true. Verified providers are linked to an existing Obot user by verified email (pkg/gateway/client/identity.go:239-243), so a Google-to-GitHub switch using the same verified address reuses the user and their work. Make the copy conditional on provider identity semantics or explain that transfer depends on verified-email matching.
  • Files reviewed: 67/68 changed files
  • Comments generated: 2
  • Review effort level: Balanced

@g-linville

Copy link
Copy Markdown
Member

btw I reviewed backend only

</div>
<p class="text-muted-content text-xs font-light">
Not the right account?
<button class="text-link underline" onclick={handleVerifyStagedProvider}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disabled={switching} here too!

Comment thread pkg/api/authz/authprovider_test.go Outdated
// Auth provider mutations disable or replace the way everyone signs in, so they belong to owners
// and administrators only. An auditor is a read-only role and must not reach them, even though it
// is granted the reveal route that sits under the same path prefix.
func TestAuthProviderMutationsAreNotAvailableToAuditors(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this test function is not indicative of what it is testing.

Comment thread pkg/api/authz/authz.go Outdated
Comment on lines +204 to +205
// Reveal is a POST but is a read. The rest of the auth-provider POSTs replace or
// disable how everyone signs in, so an auditor must not inherit them from a prefix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment requires some context. Specifically, I don't know what it is calling out about a prefix.

Comment thread pkg/api/handlers/authprovider.go Outdated
Comment on lines +213 to +215
if err := ap.license.RequireEntitlements(req.Context(), authProvider.Spec.RequiredEntitlements); err != nil {
return err
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use providers.AuthProviderStatus for this and the missing configuration stuff.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to this.

Comment thread pkg/api/handlers/authprovider.go Outdated
Comment on lines +332 to +334
if !req.UserIsOwner() {
return types.NewErrHTTP(http.StatusForbidden, "only an owner can verify a replacement auth provider")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be done in the authorization layer and not here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved.


// Activate promotes the staged provider and deconfigures the outgoing one. It requires a recorded
// verification for the staged provider, which only a successful Verify produces.
func (ap *AuthProviderHandler) Activate(req api.Context) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This this also only be available to an owner?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't at the time of your review, I've since moved all of these operations (Stage, Verify, and Activate) to be owner-only.

Comment thread pkg/api/server/server.go Outdated
// Enforced after audit logging is installed and refreshed provider cookies are replayed, so
// rejected probes stay auditable and a cookie refresh is not lost to a blocked operation.
if utils.FirstSet(user.GetExtra()["password_change_required"]...) == "true" && !passwordChangeRequestAllowed(req) {
if strings.HasPrefix(req.URL.Path, "/api/") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the intention here is to send a forbidden error for all API requests and redirect for UI requests, then this can be

Suggested change
if strings.HasPrefix(req.URL.Path, "/api/") {
if req.Pattern != "/" {

Comment thread pkg/api/server/server.go
Comment on lines +271 to +283
func passwordChangeRequestAllowed(req *http.Request) bool {
if isStaticAssetPath(req.URL.Path) || req.URL.Path == "/change-password" || strings.HasPrefix(req.URL.Path, "/change-password/") || req.URL.Path == "/oauth2/sign_out" {
return true
}
return (req.Method == http.MethodGet && slices.Contains([]string{
"/api/me",
"/api/version",
"/api/license",
"/api/app-preferences",
}, req.URL.Path)) ||
(req.Method == http.MethodPost && req.URL.Path == "/api/local-auth/change-password")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be in the authorization layer. We should have access to the user and the extra field there.

The only thing that gives me pause here is the redirect.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I played around with this, but fwict it requires doing the redirect outside the authorizer and splits up the logic. I think it's a little easier to understand without that split.

Comment thread pkg/auth/redirect_test.go Outdated
tests := []struct {
rd, want string
}{
{"", "/"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit here.

Comment thread pkg/bootstrap/bootstrap.go Outdated
return func(o *options) { o.disabled = true }
}

func New(ctx context.Context, serverURL string, c *client.Client, authProviderGetter configuredAuthProviderGetter, authEnabled, forceEnableBootstrap bool, opts ...Option) (*Bootstrap, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems weird to have authEnabled and forceEnableBootstrap be sent as bools, but disabled to be set using a functional option pattern.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


credEnv := map[string]string{}
cred, err := d.gatewayClient.RevealCredential(ctx, []string{authProvider.Name, system.GenericAuthProviderCredentialContext}, authProvider.Name)
cred, err := d.gatewayClient.RevealCredential(ctx, []string{authProvider.Name, system.GenericAuthProviderCredentialContext, system.ReplacementAuthProviderCredentialContext}, authProvider.Name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is why you did it this way in the other place where I commented you can use providers.AuthProviderStatus. Does it make sense to include this credential context there, too?

Copilot AI review requested due to automatic review settings September 4, 2026 04:15
@njhale
njhale force-pushed the local-auth-bootstrap branch from 18ef021 to c58c95d Compare September 4, 2026 04:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Seven unresolved moderate issues affect provisioning, provider-switch safety, and update correctness.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (6)

pkg/api/handlers/authprovider.go:271

  • The new settings are committed before the old verification is cleared. If this cleanup fails transiently, the endpoint returns an error but the replacement settings remain staged and the old cache remains; Activate can then accept proof recorded for different credentials. Invalidate before committing, or bind the verification to a staged-credential revision so activation rejects stale proof.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/api/handlers/authprovider.go:271

  • Clearing only the cached result does not invalidate an OAuth verification already in flight. A callback started against settings A can arrive after this request stages settings B, repopulate the cache (the callback checks only the provider name), and then authorize activation of unverified settings B. Bind verification requests to the staged credential revision and reject/invalidate older requests when re-staging.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:255

  • This switch is not retry-safe once deconfiguration starts. deconfigureAuthProvider deletes the outgoing credential before fallible session/table cleanup, and later status, daemon-sync, and staged-credential operations can also fail. A retry then sees the replacement as configured and line 227 records a terminal conflict, so activation can return failure after Local/current auth has already been disabled, contrary to the staged-switch failure guarantee. Make the switched reconcile idempotently resume after cutover or restore the outgoing credential on failure.
		if err := h.deconfigureAuthProvider(ctx, client, outgoingProvider); err != nil {
			return err
		}

pkg/gateway/server/oauth.go:155

  • The verification is cached before owner promotion succeeds. If promotion is rejected (for example, for an explicitly configured Admin), the callback returns an error but leaves this cache entry behind; Activate checks only its provider name, so another Owner can activate a replacement whose verified identity never became Owner. Promote first and cache only after promotion succeeds.
    pkg/localauth/users.go:138
  • Provisioning is latched only by the currently configured email. After owner A completes setup, changing the deployment email to B makes this lookup miss and the creation path below provisions a second pending initial owner. This contradicts the accepted design that changing owner settings after completion must not create another setup link. Persist or query a one-time provisioning marker independent of the mutable email before creating an initial owner.
    ui/user/src/lib/components/admin/McpServerEntryForm.svelte:737
  • This catch turns a failed instance lookup into a successful-looking entry update. When existing deployments do exist, the failure prevents showUpdateExistingDeploymentsConfirm from being set, so users are never offered the propagation step and those deployments remain stale while the success message is shown below. Surface the lookup error or defer success until the deployment check can be retried.
  • Files reviewed: 68/69 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread pkg/api/handlers/setup/confirm_owner.go
Copilot AI review requested due to automatic review settings September 4, 2026 04:47
@njhale
njhale force-pushed the local-auth-bootstrap branch from c58c95d to ab6c4f4 Compare September 4, 2026 04:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Five unresolved moderate issues affect owner provisioning and provider-switch security and recovery.

Review details

Suppressed comments (5)

pkg/api/handlers/authprovider.go:271

  • Clearing only the completed cache does not invalidate OAuth verifications already in flight. If an owner starts verification and then re-stages this same provider before the callback returns, that callback still passes (the token is bound only to the provider name), repopulates the cache, and lets Activate promote credentials that were never used for the sign-in. Bind each verification to a staged credential revision/hash and reject or revoke it when staging changes.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:228

  • This conflict check makes the switch non-idempotent after the incoming credential is promoted. If any later step fails after the outgoing credential is removed (status update, daemon revision, staged cleanup, etc.), the next reconcile sees the incoming provider as configured and terminally rejects its own partially applied change, leaving cleanup/status incomplete while the API reports failure. Treat configuredProvider == authProvider.Name as a resumable partial switch and make the remaining cleanup steps idempotent.
		if configuredProvider != change.Spec.ReplacesProviderName {
			return &authProviderConflictError{configuredProvider: configuredProvider}

pkg/gateway/server/oauth.go:156

  • This durable Owner grant has no rollback when the switch is discarded, re-staged, or the user chooses “Sign in again”; those paths only clear TempSetupUser. Every identity used in an abandoned verification therefore remains Owner and can regain privileged access if this provider is staged or configured later. Preserve the cached prior role and restore it whenever verification is replaced/discarded, or use a restricted switch-completion capability instead of persisting Owner before activation.
    pkg/gateway/server/oauth.go:156
  • The verification cache is committed before promotion succeeds. If promotion is rejected (for example, the identity is an environment-configured Admin) or fails, the callback returns an error but the cache still makes the provider appear verified, and another Owner can activate based on that stale row. Remove the cache on this failure path.
    pkg/localauth/users.go:142
  • Provisioning is only made idempotent for the currently configured email. After the original owner completes setup, changing LOCAL_AUTH_INITIAL_OWNER_EMAIL makes this lookup miss and the function falls through to CreateInitialLocalAuthUser, creating a second activation link/account. That violates the accepted one-initial-owner behavior and the requirement that changing deployment settings after completion cannot create another setup link. Persist/check completion independently of the current email before creating a new pending owner.
  • Files reviewed: 68/69 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI review requested due to automatic review settings September 4, 2026 05:08
@njhale
njhale force-pushed the local-auth-bootstrap branch from ab6c4f4 to eaf56a4 Compare September 4, 2026 05:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical verification-cache handling and non-idempotent provider-switch recovery can permit unsafe activation or leave switching incomplete.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

pkg/api/handlers/authprovider.go:271

  • Clear the previous verification before replacing the staged credential. As written, the controller has already persisted the new settings when ClearTempUserCache runs; if that cleanup fails, the request returns an error but the old cached verification remains valid for the newly staged settings, so Activate can promote settings that were never verified.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:228

  • This precondition makes the switch non-idempotent after partial success. Once the incoming credential is promoted and deconfigureAuthProvider deletes the outgoing credential, any later failure (session cleanup, status update, daemon revision, or staged-credential deletion) causes the retry to observe the incoming provider as configured and record a conflict here. The remaining cleanup can then never finish, even though activation reports failure and Local may already be disabled. Model the switch as resumable phases (or otherwise recognize the already-promoted replacement) so reconciliation can complete after failures.
		if configuredProvider != change.Spec.ReplacesProviderName {
			return &authProviderConflictError{configuredProvider: configuredProvider}
  • Files reviewed: 68/69 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread pkg/gateway/server/oauth.go Outdated
Comment thread docs/docs/installation/enabling-authentication.md
Obot serves logins from one configured auth provider, and "configured"
means holding a complete credential in that provider's own context. That
made both getting the first owner and changing providers hazardous: the
former required a bootstrap token and manual provider setup, and the
latter had a window with nothing configured and no session left to fix it.

A provisioned deployment can now name an initial Local owner from an email
and a high-entropy setup token. Obot configures Local, creates the account
with an unusable password, and stores only the token's hash. The activation
link carries the token in its fragment, so it never reaches a request line
or an access log. Until a password is set, the session is restricted to the
password page and the handful of endpoints it needs. Completion is atomic
and single-winner. Bootstrap is disabled while this is configured.

A replacement provider's settings are staged into a separate credential
context, so it is not configured, does not serve logins, and survives a
restart without changing who does. A one-time login through it, authorized
only for the owner who started the switch and pinned to their browser,
proves it works and produces the identity that will hold Owner. Activation
promotes the staged credential before deconfiguring the outgoing provider,
in one reconcile, so a partial failure still leaves someone able to sign
in. Deconfiguring the provider currently serving logins is refused.

Signed-off-by: Nick Hale <4175918+njhale@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 4, 2026 05:43
@njhale
njhale force-pushed the local-auth-bootstrap branch from eaf56a4 to 789e293 Compare September 4, 2026 05:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One critical authorization issue and four moderate correctness issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

pkg/api/handlers/authprovider.go:271

  • Clearing only the cached result does not retire verification token requests that were issued for the previous settings. An in-flight old OAuth flow can finish after this clear, repopulate the cache for the same provider name, and make Activate promote the newly staged credentials even though the successful login used the prior configuration. Bind verification requests to a staged-credential generation and invalidate/check that generation atomically when settings are replaced.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:228

  • Make the switch retry-safe. Once the replacement credential is promoted and the outgoing credential is removed below, any transient failure in the status, daemon-revision, or staged-credential cleanup steps causes the next reconcile to see the replacement as configuredProvider. This check then records a terminal conflict, so reconciliation cannot finish and the API can report failure even though the login provider already changed. Treat the already-promoted state as resumable (or persist explicit phases) and continue the remaining cleanup.
		if configuredProvider != change.Spec.ReplacesProviderName {
			return &authProviderConflictError{configuredProvider: configuredProvider}

pkg/localauth/users.go:138

  • The idempotency check is scoped only to the currently configured email. After the first owner completes setup, changing the deployment email makes this lookup miss and the code below creates a second pending “initial” owner and setup link. The accepted design explicitly excludes creating more than one initial owner and states that changing the owner email after completion must not create a new setup link. Persist and check installation-wide completion state independently of the current email before creating another pending owner.
  • Files reviewed: 68/69 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread pkg/api/handlers/authprovider.go
@njhale

njhale commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Integration tests will fail until #7757 is in (in, tests passing)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants